//------------------------------------------------------------------------------
// <copyright company="Telligent Systems">
//     Copyright (c) Telligent Systems Corporation.  All rights reserved.
// </copyright> 
//------------------------------------------------------------------------------

//refactored by Rob Conery on 8/9/2007 for use with SubSonic Forums... hope this is OK :)



using System;
using System.Collections.Specialized;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
using System.Web.Caching;
using System.Xml;

namespace SubSonic.Forums.Data {
    /// <summary>
    /// Validates Html only letting a predefined set of Elemtnts/Attributes remain.
    /// </summary>
    public class HtmlScrubber {
        #region Static Default
        private static Dictionary<string, Dictionary<string, bool>> DefaultTags()
        {
            Dictionary<string, Dictionary<string, bool>> defaultTags = new Dictionary<string, Dictionary<string, bool>>();
            Dictionary<string, bool> align = align;
            Dictionary<string, bool> none  = none;
            defaultTags.Add("h1", align);
            defaultTags.Add("h2", align);
            defaultTags.Add("h3", align);
            defaultTags.Add("h4", align);
            defaultTags.Add("h5", align);
            defaultTags.Add("h6", align);
            defaultTags.Add("strong", none);
            defaultTags.Add("em", none);
            defaultTags.Add("u", none);
            defaultTags.Add("b", none);
            defaultTags.Add("i", none);
            defaultTags.Add("strike", none);
            defaultTags.Add("sup", none);
            defaultTags.Add("sub", none);
            defaultTags.Add("blockquote", ParseAttributeList("dir"));
            defaultTags.Add("ul", none);
            defaultTags.Add("ol", none);
            defaultTags.Add("li", none);
            defaultTags.Add("p", none);
            defaultTags.Add("address", none);
            defaultTags.Add("pre", ParseAttributeList("class"));
            defaultTags.Add("div", align);
            defaultTags.Add("hr", none);
            defaultTags.Add("br", none);
            defaultTags.Add("a", ParseAttributeList("href,target,name"));
            defaultTags.Add("span", align);
            defaultTags.Add("img", ParseAttributeList("src,alt,title"));

            return defaultTags;
        }

        private static Dictionary<string, bool> ParseAttributeList(string attributeList) 
        {
            Dictionary<string, bool> atts = new Dictionary<string, bool>();
            foreach (string att in attributeList.Split(',')) 
            {
                if (!string.IsNullOrEmpty(att))
                    atts[att.ToLower()] = true;
            }
            return atts;
        }

        private static Dictionary<string, bool> DefaultSelfContainedTags() 
        {
            Dictionary<string, bool> defaultTags = new Dictionary<string, bool>();
            defaultTags.Add("br", true);
            defaultTags.Add("img", true);
            defaultTags.Add("input", false);
            defaultTags.Add("meta", false);
            defaultTags.Add("base", false);
            defaultTags.Add("hr", true);

            return defaultTags;
        }

        protected Dictionary<string, Dictionary<string, bool>> allowedTags = null;
        protected Dictionary<string, bool> selfContainedTags = null;
        private static Regex regex = new Regex("<[^<>]+>?", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline);
        private static Regex jsAttributeRegex = new Regex("javascript:", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);
        private static Regex xmlLineBreak = new Regex("&#x([DA9]|20|85|2028|0A|0D)(;)?", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);
        private static Regex filterdCharacters = new Regex("\\=|\\\"|\\'|\\s", RegexOptions.Compiled);
        private static Regex validProtocols = new Regex("^((http(s)?|mailto|mms):|/)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
        private static Regex bannedChars = new Regex("\\s", RegexOptions.Compiled);
        private static Regex styleProperty = new Regex(@"^[a-z\-]*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
        private static Regex styleValue = new Regex(@"^(\#?[a-z0-9\.\-\,\']*\%?|\s+|url\(\'?(?:(?:http(s)?|mailto|mms):|/)[^\;\:\)\<\>\n\r\*\""\'\\]*?\'?\))+$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
        #endregion

        #region Private members
        string input = null;
        StringBuilder output = new StringBuilder();
        bool cleanJS = false;
        bool isFormatted = false;
        bool encodeExceptions = false;
        #endregion

        #region Cleaners
        /// <summary>
        /// Returns the results of a cleaning.
        /// </summary>
        /// <returns></returns>
        public string Clean() {
            if (!isFormatted) {
                Format();
                isFormatted = true;
            }
            return output.ToString();
        }

        #endregion

        #region Format / Walk
        string EnsureHtmlEncode(string s) {
            string result = "";
            if(!String.IsNullOrEmpty(s))
                result = System.Web.HttpUtility.HtmlEncode(s);

            return result;
        }
        /// <summary>
        /// Walks one time through the HTML. All elements/tags are validated.
        /// The rest of the text is simply added to the internal queue
        /// </summary>
        protected virtual void Format() {
            int lastEndIndex = 0;
            Match match = regex.Match(input);

            while (match.Value != string.Empty) {
                if (lastEndIndex != match.Index) {
                    // add whatever text was between the tags
                    output.Append(EnsureHtmlEncode(input.Substring(lastEndIndex, match.Index - lastEndIndex)));
                }

                output.Append(Validate(match.Value));

                //Get the next match
                lastEndIndex = match.Index + match.Length;
                match = regex.Match(input, lastEndIndex);
            }

            // add whatever text exists after the last tag
            if (lastEndIndex < input.Length)
                output.Append(EnsureHtmlEncode(input.Substring(lastEndIndex)));
        }

        #endregion

        #region Validators

        /// <summary>
        /// Main method for starting element validation
        /// </summary>
        /// <param name="tag"></param>
        /// <returns></returns>
        protected string Validate(string tag) {
            if (tag.StartsWith("</"))
                return ValidateEndTag(tag);

            if (tag.EndsWith("/>"))
                return ValidateSingleTag(tag);

            return ValidateStartTag(tag);

        }

        /// <summary>
        /// Validates single element tags such as <br /> and <hr class = "X" />
        /// </summary>
        private string ValidateSingleTag(string tag) {
            string strip = tag.Substring(1, tag.Length - 3).Trim();

            int index = strip.IndexOfAny(new char[] { ' ', '\r', '\n' });
            if (index == -1)
                index = strip.Length;

            string tagName = strip.Substring(0, index).ToLower();
            Dictionary<string, bool> allowedAttributes = null;
            if (!allowedTags.TryGetValue(tagName, out allowedAttributes))
                return encodeExceptions ? EnsureHtmlEncode(tag) : string.Empty;

            string atts = strip.Substring(tagName.Length).Trim();

            return ValidateAttributes(allowedAttributes, atts, tagName, "<{0}{1} />");
        }

        /// <summary>
        /// Validates a start tag
        /// </summary>
        /// <param name="tag"></param>
        /// <returns>the tag and validate attributes</returns>
        protected virtual string ValidateStartTag(string tag) {
            //Check for potential attributes
            int endIndex = tag.IndexOfAny(new char[] { ' ', '\r', '\n' });

            //simple tag <tag>
            if (endIndex == -1)
                endIndex = tag.Length - 1;

            //Grab the tab name
            string tagName = tag.Substring(1, endIndex - 1).ToLower();



            //Use safe incase a : is present
            Dictionary<string, bool> allowedAttributes = null;
            if (!allowedTags.TryGetValue(tagName, out allowedAttributes)) {
                //If we do not find a record in the Hashtable, this tag is not valid
                return encodeExceptions ? EnsureHtmlEncode(tag) : string.Empty; //remove element and all attributes if not valid
            }

            //remove the tag name and find all of the current element's attributes
            int start = tagName.Length;

            string attributes = tag.Substring(start + 1, (tag.Length - (start + 2)));

            //if we have attributes, make sure there is no extra padding in the way
            if (attributes != null)
                attributes = attributes.Trim();

            //Validate the attributes
            if (selfContainedTags.ContainsKey(tagName))
                return ValidateAttributes(allowedAttributes, attributes, tagName, "<{0}{1} />");
            else
                return ValidateAttributes(allowedAttributes, attributes, tagName, "<{0}{1}>");
        }

        /// <summary>
        /// Validates the elements attribute collection
        /// </summary>
        /// <param name="allowedAttributes"></param>
        /// <param name="tagAttributes"></param>
        /// <param name="tagName"></param>
        /// <returns></returns>
        protected virtual string ValidateAttributes(Dictionary<string, bool> allowedAttributes, string tagAttributes, string tagName, string tagFormat) {
            //container for attributes. 
            StringBuilder atts = new StringBuilder();
            bool hasAlt = false;

            //Do we even have any attributes to validate?
            if (allowedAttributes.Count > 0) {
                tagAttributes = xmlLineBreak.Replace(tagAttributes, string.Empty);

                for (int start = 0, end = 0; start < tagAttributes.Length; start = end) {
                    //Put the end index at the end of the attribute name.
                    end = tagAttributes.IndexOf('=', start);
                    if (end < 0)
                        end = tagAttributes.Length;

                    //Get the attribute name and see if it's allowed.
                    string att = tagAttributes.Substring(start, end - start).Trim();

                    bool allowed = allowedAttributes.ContainsKey(att.ToLower());

                    //Now advance the end index to include the attribute value.
                    if (end < tagAttributes.Length) {
                        //Skip any blanks after the '='.
                        for (++end; end < tagAttributes.Length && (tagAttributes[end] == ' ' || tagAttributes[end] == '\r' || tagAttributes[end] == '\n'); ++end) ;

                        if (end < tagAttributes.Length) {
                            //Find the end of the value.
                            end = tagAttributes[end] == '"' //Quoted with double quotes?
                                ? tagAttributes.IndexOf('"', end + 1)
                                : tagAttributes[end] == '\'' //Quoted with single quotes?
                                ? tagAttributes.IndexOf('\'', end + 1)
                                : tagAttributes.IndexOfAny(new char[] { ' ', '\r', '\n' } , end); //Otherwise, assume not quoted.

                            //If we didn't find the terminating character, just go to the end of the string.
                            //Otherwise, advance the end index past the terminating character.
                            end = end < 0 ? tagAttributes.Length : end + 1;
                        }
                    }

                    //If the attribute is allowed, copy it.
                    if (allowed) {
                        att = att.ToLower();
                        string attValue = tagAttributes.Substring(start, end - start).Trim();
                        attValue = attValue.Substring(att.Length).Trim().Substring(1).Trim();
                        if (attValue.Substring(0, 1) == "\"" || attValue.Substring(0, 1) == "'")
                            attValue = attValue.Substring(1, attValue.Length - 2);

                        //Special actions on these attributes. IE will render just about anything that looks like the word javascript:
                        //this includes line breaks, special characters codes, etc.
                        if (att == "src" || att == "href") {
                            attValue = EnsureHtmlEncode(attValue);

                            //Encode spaces
                            attValue = attValue.Replace(" ", "%20");

                            //validate only http, https, mailto, and / (relative) requests are made
                            if (validProtocols.IsMatch(attValue)) {
                                atts.Append(" ");
                                atts.Append(att);
                                atts.Append("=\"");
                                atts.Append(attValue);
                                atts.Append("\"");
                            }

                            //If the "if" above fails, we do not render the attribute!

                        } else if (att == "style") {
                            // convert to string builder for replacements
                            StringBuilder oldAttValue = new StringBuilder(attValue);
                            // replace double quot w/ single quote
                            oldAttValue.Replace("&quot;", "'");
                            // replace #34 w/ single quote
                            oldAttValue.Replace("&#34;", "'");
                            // replace #39 w/ single quote
                            oldAttValue.Replace("&#39;", "'");

                            string[] nameValues = oldAttValue.ToString().Split(';');

                            StringBuilder newAttValue = new StringBuilder();

                            for (int i = 0; i < nameValues.Length; i++) {
                                string[] nameValue = nameValues[i].Split(':');
                                if (nameValue.Length == 2) {
                                    nameValue[0] = nameValue[0].Trim();
                                    nameValue[1] = nameValue[1].Trim();
                                    if (styleProperty.IsMatch(nameValue[0]) && styleValue.IsMatch(nameValue[1])) {
                                        newAttValue.Append(nameValue[0]);
                                        newAttValue.Append(":");
                                        newAttValue.Append(nameValue[1]);
                                        newAttValue.Append(";");
                                    }
                                }
                            }

                            if (newAttValue.Length > 0) {
                                atts.Append(" style=\"");
                                atts.Append(EnsureHtmlEncode(newAttValue.ToString()));
                                atts.Append("\"");
                            }
                        } else {
                            if (att == "alt")
                                hasAlt = true;

                            atts.Append(" ");
                            atts.Append(att);
                            atts.Append("=\"");
                            atts.Append(EnsureHtmlEncode(attValue));
                            atts.Append("\"");
                        }


                    }
                }
                //Are we filtering for Javascript?
                if (cleanJS)
                    atts = new StringBuilder(jsAttributeRegex.Replace(atts.ToString(), string.Empty));
            }

            if (tagName == "img" && !hasAlt)
                atts.Append(" alt=\"\"");

            return string.Format(tagFormat, tagName, atts.ToString());
        }


        /// <summary>
        /// Validate End/Closing tag
        /// </summary>
        /// <param name="tag"></param>
        /// <returns></returns>
        protected virtual string ValidateEndTag(string tag) {
            string tagName = tag.Substring(2, tag.Length - 3).ToLower();

            if (!allowedTags.ContainsKey(tagName))
                return encodeExceptions ? EnsureHtmlEncode(tag) : string.Empty;
            else if (selfContainedTags.ContainsKey(tagName))
                return string.Empty;
            else
                return tag.ToLower();
        }

        #endregion
    }
}

